

# Создание нейронной сети с нуля на Python: пошаговое руководство

Оглавление
- [Создание нейронной сети с нуля на Python: пошаговое руководство](#создание-нейронной-сети-с-нуля-на-python-пошаговое-руководство)
  - [Аннотация](#аннотация)
  - [1 Функции активации](#1-функции-активации)
    - [1.1 Sigmoid](#11-sigmoid)
    - [1.2 Выпрямленная линейная единица ReLU (Rectified Linear Unit)](#12-выпрямленная-линейная-единица-relu-rectified-linear-unit)
    - [1.3. Leaky ReLU](#13-leaky-relu)
    - [1.4 Tanh](#14-tanh)
    - [1.5. Softmax](#15-softmax)
  - [2. Дополнительные слои](#2-дополнительные-слои)
  - [3. Скрытые узлы](#3-скрытые-узлы)
  - [4 Вычисление выходных значений](#4-вычисление-выходных-значений)
    - [4.1 Прямой проход](#41-прямой-проход)
    - [4.2 Функция ошибки](#42-функция-ошибки)
    - [4.3 Обратный проход](#43-обратный-проход)
    - [4.4 Градиенты 1-го слоя](#44-градиенты-1-го-слоя)
  - [5 Тренировка сети](#5-тренировка-сети)
  - [6. Совокупный код](#6-совокупный-код)

## Аннотация 

Настоящее руководство основано на статье [Okan Yenigün](https://medium.com/@okanyenigun/building-a-neural-network-from-scratch-in-python-a-step-by-step-guide-8f8cab064c8a).

Назначение данной статьи - связать теорию с кодом, который размещен в репозитории https://gitverse.ru/new_horizons/NeuralNetworks.

Репозиторий можно клонировать, установить все зависимости из requirements.txt и сразу проверить и изучить его работу.

Руководство находится в процессе перевода, дополнения пояснениями и примерами и содержит рабочие файлы с кодом.

В файле `installation_article2.md` написан порядок настройки окружения для выполнения основного кода и примеров.

На данный момент в репозитории для данной статьи представлены файлы с кодом:
* `article2_v2` - создание и обучение нейросети;
* `matplot_examples` - рабочая заготовка для построения графиков функций, использованных в основном коде;
* `tsensor_examples` - рабочая заготовка для визуализации тензорных переменных.

## 1 Функции активации

```
import numpy as np
import matplotlib.pyplot as plt- [Создание нейронной сети с нуля на Python: пошаговое руководство](#создание-нейронной-сети-с-нуля-на-python-пошаговое-руководство)


def plot_func(x,y, title):
    """Вспомогательная функция для построения графиков функций активации.

    Args:
        x (numpy.ndarray): Массив значений по оси x.
        y (numpy.ndarray): Массив значений функции активации, соответствующих значениям x.
        title (str): Заголовок графика.
    """

    plt.plot(x, y)  # Построить график зависимости y от x.
    plt.title(title)  # Установить заголовок графика.
    plt.xlabel('x')  # Подписать ось x меткой "x".
    plt.ylabel('activation(x)')  # Подписать ось y меткой "activation(x)".
    plt.grid(True)  # Включить сетку на графике.
    plt.show()     # Отобразить график.

x = np.linspace(-10, 10, 100)
```

Библиотека Matplotlib (plt): построение различных типов визуализаций (графиков, диаграмм и т.д.).
Библиотека NumPy (np): функции для работы с многомерными массивами и математическими операциями.

np.linspace(-10, 10, 100): Создает массив x с помощью функции linspace из библиотеки NumPy. Эта функция генерирует 100 значений, равномерно распределенных между -10 (включительно) и 10 (включительно).

Использование функции plot_func (предполагается, что у вас есть массив значений функции активации y):

plot_func(x, y, "График функции активации"): Вызывает функцию plot_func, передавая ей созданный массив x, массив значений функции активации (y) и заголовок графика ("График функции активации").

### 1.1 Sigmoid

Функция сигмоидной активации - это математическая функция, которая отображает входное значение в значение между 0 и 1, образуя гладкую S-образную кривую.

```
def sigmoid(x):
    return 1 / (1 + np.exp(-x))
```

![Sigmoid](./resources/sigmoid.webp)

Функция активации ReLU (выпрямленная линейная единица) - это математическая функция, которая возвращает входное значение, если оно положительное, и ноль в противном случае, обеспечивая кусочно-линейный выход.

### 1.2 Выпрямленная линейная единица ReLU (Rectified Linear Unit)

```
def relu(x):
    return np.maximum(0, x)
```

![Rectified Linear Unit (ReLU)](./resources/ReLU.webp)

Функция активации ReLU (выпрямленная линейная единица) - это математическая функция, которая возвращает входное значение, если оно положительное, и ноль в противном случае, обеспечивая кусочно-линейный выход.

### 1.3. Leaky ReLU

Функция активации Leaky ReLU является модификацией функции ReLU, которая допускает небольшой ненулевой выход для отрицательных входных значений, предотвращая тем самым полное подавление информации.



```
def relu(x):
    return np.maximum(0, x)
```


![Leaky relu function](./resources/leakyReLU.webp)

### 1.4 Tanh

Функция активации tanh (гиперболический тангенс) - это математическая функция, которая отображает входное значение в диапазон от -1 до 1, образуя гладкую S-образную кривую, центрированную вокруг нуля.

```
def tanh(x):
    return np.tanh(x)
```
![Функция активации tanh (гиперболический тангенс)](./resources/tahn.webp)


### 1.5. Softmax

Функция активации softmax - это математическая функция, широко используемая в задачах многоклассовой классификации для преобразования вектора действительных чисел в вероятностное распределение по нескольким классам.

```
def softmax(x):
    exp_scores = np.exp(x)
    return exp_scores / np.sum(exp_scores)
```

![Softmax](./resources/softmax.webp)

В нашей реализации мы продолжим использовать функцию активации ReLU.

## 2. Дополнительные слои

Использование только функции активации ReLU значительно увеличит ошибку. Прежде всего, это связано с тем, что для любого входного значения меньше нуля она будет выдавать одинаковые предсказания. Для решения этой проблемы мы вводим несколько слоев в нашу нейронную сеть. Например, нейронную сеть с двумя слоями можно представить уравнением y^ = w2 * relu(w1 * x + b1) + b2.


## 3. Скрытые узлы

В данном случае, функция lambda, названная prediction (предсказание), рассчитывает линейное предсказание на основе входного значения x. При этом она использует заранее определённые веса (w1) и смещение (b).

```
prediction = lambda x, w1=.2, b=1.99: x * w1 + b
```

Затем, к полученным линейным предсказаниям мы применяем функцию активации ReLU.

```
layer1_1 = np.maximum(0, prediction(x))
plt.plot(x, layer1_1)
```

![layer1_1](./resources/layer1_1.webp)

Что произойдет, если мы добавим еще слой?


```
layer1_2 = np.maximum(0, prediction(x, .3, -2))
plt.plot(x, layer1_1+layer1_2)
```

![layer1_2](./resources/layer1_2.webp)

Мы добавили функцию активации на выходе. Давайте добавим еще одну.

```
layer1_3 = np.maximum(0, prediction(x, .6, -2))
plt.plot(x, layer1_1+layer1_2+layer1_3)
```

![layer1_3](./resources/layer1_3.webp)

По мере увеличения количества единиц, мы наблюдаем появление более выраженной нелинейной зависимости. По мере того, как мы регулируем веса, мы можем наблюдать соответствующие изменения в зависимости.

Давайте построим диаграмму для двуслойной структуры.

![Diagram for a two-layer structure](./resources/diagram.webp)

## 4 Вычисление выходных значений

Как видно из вышесказанного, результат одного компонента служит входным сигналом для другого. Для вычисления выходных значений в этом процессе используется матричное умножение.

![Matrix multiplication](./resources/calculatingOutput1.gif)


Допустим, у нас есть входные данные, представленные матрицей размерности 2x1.

![Matrix multiplication](./resources/calculatingOutput2.webp)

### 4.1 Прямой проход

Пакет [tsensor](https://gitverse.ru/new_horizons/tensor-sensor) может эффективно использоваться для визуализации тензорных переменных. TensorSensor улучшает понимание формы тензорных переменных за счет более информативных сообщений об ошибках и отображения исходного кода Python. Он совместим с популярными библиотеками, такими как TensorFlow, PyTorch, JAX, NumPy, Keras и fastai.

Примечание. В файле `matplot_examples` содержится рабочая заготовка для построения графиков функций.


```
from tsensor import explain as exp

x_input = np.array([[10], [20], [-20], [-40], [-3]])

# 1x2 weight matrix
l1_weights = np.array([[.73, .2]])

# 1x2 bias matrix
l1_bias = np.array([[4, 2]])

# output
with exp() as c:
    l1_output = x_input @ l1_weights + l1_bias
```

![tsensor output](./resources/forwardPass1.webp)

![outputs](./resources/forwardPass2.webp)


Затем к выходным данным этого процесса мы применяем функцию активации.

```
l1_activated = relu(l1_output)
```

![l1 activated](./resources/forwardPass3.webp)

![](./resources/forwardPass4.webp)

Полезное правило заключается в том, что количество строк в весовой матрице должно совпадать с количеством столбцов во входной матрице, а количество столбцов в весовой матрице должно совпадать с количеством столбцов в выходной матрице. Следовательно, в первом слое наша весовая матрица имеет размерность 1x2, что означает переход от 1 входного признака к 2 выходным признакам.

![](./resources/forwardPass5.webp)


![](./resources/forwardPass6.webp)

В сущности, это фундаментальный процесс создания предсказаний в нейронных сетях. Он включает в себя использование матрицы весов и матрицы смещения для каждого слоя, выполнение повторяющихся умножений на матрицу весов, добавление смещения, применение нелинейности и повторение этого процесса для каждого слоя. Чтобы учесть дополнительные единицы внутри слоя, вы можете просто добавить больше столбцов в матрицу весов.

Основополагающий процесс создания предсказаний в нейронных сетях:

1. Использование весовой матрицы и матрицы смещения для каждого слоя: Весовая матрица определяет силу связей между нейронами, а матрица смещения регулирует выход каждого нейрона.
2. Многократное перемножение на весовую матрицу: Каждый элемент входного вектора перемножается на соответствующие веса, полученные из весовой матрицы.
3. Добавление смещения: К полученному после перемножения значению добавляется смещение.
4. Применение функции активации: Функция активации вводит нелинейность в сеть, позволяя ей моделировать более сложные зависимости.
5. Повторение для каждого слоя: Весь процесс - перемножение, смещение, активация - повторяется для каждого слоя в нейронной сети.
6. Увеличение размерности весовой матрицы: Чтобы учесть большее количество нейронов в слое, просто добавляются столбцы в весовую матрицу.


### 4.2 Функция ошибки

Теперь мы можем использовать среднеквадратическую ошибку (или любую другую метрику) для расчета ошибки между нашим выходным сигналом и фактическими значениями.

```
def calculate_mse(actual, predicted):
    return (actual - predicted) ** 2

actual = np.array([[9], [13], [5], [-2], [-1]])

print(calculate_mse(actual,output))

"""
[[6.4000000e-03]
 [9.2160000e-01]
 [1.0000000e+00]
 [3.6000000e+01]
 [3.4386496e+01]]
"""
```

Во время градиентного спуска  критически важно определить градиент функции потерь, который представляет собой скорость изменения. Этот градиент показывает, как функция потерь изменяется по мере того, как мы меняем входные значения.

```
def gradient_mse(actual, predicted):
    return predicted - actual

print(gradient_mse(actual,output))

"""
[[-0.08 ]
 [-0.96 ]
 [-1.   ]
 [ 6.   ]
 [ 5.864]]
"""
```


Эта информация  показывает направление необходимой корректировки нашего прогноза для эффективного минимизации ошибки.

### 4.3 Обратный проход


Обратное распространение (backpropagation), по сути, обращает прямой проход, чтобы распределить градиент по различным параметрам сети, таким как веса и смещения. Этот градиент играет решающую роль в обучении сети с помощью алгоритма градиентного спуска.

Прямой проход:

![](./resources/backwardPass1.webp)

Мы разворачиваем этот процесс, чтобы получить обратное распространение.

![](./resources/backwardPass2.webp)

Вычисляем частную производную функции потерь для каждого параметра сети. Градиент выходного слоя, также называемый градиентом L2, используется для обновления весов во втором слое. Обновление выполняется путем перемножения входных данных второго слоя и выходных данных первого слоя (из прямого прохода) с градиентом L2. Смещение в слое обновляется путем взятия среднего значения градиента по выходу.

Далее, мы распространяем градиент на первый слой, умножая его на веса второго слоя. Затем пропускаем этот градиент через функцию ReLU и используем его для обновления весов и смещения в первом слое.

```
output_gradient = gradient_mse(actual, output)

with exp():
    l2_w_gradient =  l1_activated.T @ output_gradient
l2_w_gradient

"""
array([[-8.14616],
       [ 2.1296 ]])
"""
```

![](./resources/backwardPass3.webp)

Взяли транспонированную матрицу l1_activated размером 5x2 (теперь она стала 2x5), которая была выходным сигналом 1-го слоя в прямом проходе, и перемножили ее с нашим градиентом выходного слоя output_gradient размером 5x1. В результате получили матрицу размером 2x1. Эта матрица показывает нам градиент для каждого значения в нашей матрице весов w2.

![](./resources/backwardPass4.webp)

Предоставленная диаграмма показывает, как входы умножаются на веса во время прямого прохода для получения выходного сигнала.  Очевидно, что каждому весу соответствует несколько входов, а также каждый вес связан как с несколькими входами, так и с несколькими выходами.

![](./resources/backwardPass5.webp)

Для получения градиента весов мы транспонируем выход слоя 1 и перемножаем его с градиентом выхода.

![](./resources/backwardPass6.webp)

Для того, чтобы каждый входной элемент, связанный с выходом через вес, был умножен на соответствующий градиент выхода, мы транспонируем матрицу выходных значений первого слоя (которая является входными данными для второго слоя).

Перемножая градиенты выхода с входными данными слоя, мы можем определить необходимые корректировки весов. Эта связь возникает из применения правила цепочки для частных производных.

![](./resources/backwardPass7.webp)


С помощью правила цепочки для частных производных мы вычисляем частную производную ошибки по отношению к второй матрице весов.  Это включает в себя умножение частной производной ошибки на частную производную произведения x и w2 по w2. Решение этого уравнения позволяет нам определить подходящие значения для матрицы весов.

Далее, мы вычисляем производную для смещения.

```
with exp():
    l2_b_gradient =  np.mean(output_gradient, axis=0)

l2_b_gradient

"""
array([1.9648])
"""
```

![](./resources/backwardPass8.webp)

Для обновления весов и смещения в слое 2 мы вычитаем из их текущих значений (w и b) градиент, умноженный на коэффициент обучения (learning rate). Коэффициент обучения помогает предотвратить слишком большие обновления, которые могут привести к тому, что мы отойдем от оптимального решения с минимальной ошибкой.

```
# Set a learning rate
lr = 1e-4

with exp():
    # Update the bias values
    l2_bias = l2_bias - l2_b_gradient * lr
    # Update the weight values
    l2_weights = l2_weights - l2_w_gradient * lr

l2_weights

"""
array([[0.40171069],
       [0.09955278]])
"""
```
Обновляем l2-биасы и l2-веса (L2-регуляризация).

Примечание. L2-регуляризация - это техника, которая помогает предотвратить переобучение нейронной сети. Она достигается путем добавления штрафа за слишком большие веса и смещения к функции ошибки.



![](./resources/backwardPass9.webp)


![](./resources/backwardPass10.webp)


### 4.4 Градиенты 1-го слоя

Далее мы переходим к вычислению градиентов для слоя 1. Выходные значения слоя 1 получаются путем масштабирования входных данных соответствующими весами, что приводит к выходным данным слоя 2. Чтобы определить градиент ошибки по отношению к выходным данным слоя 1, мы обращаем прямой проход, масштабируя градиент выхода весами слоя 2.

```
with exp():
    # Calculate the gradient on the output of layer 1
    l1_activated_gradient = output_gradient @ l2_weights.T

l1_activated_gradient

"""
array([[-0.03213686, -0.00796422],
       [-0.38564227, -0.09557067],
       [-0.40171069, -0.09955278],
       [ 2.41026416,  0.5973167 ],
       [ 2.35563151,  0.58377753]])
"""
```

![](./resources/layer1Gradients1.webp)

Переходя далее, мы приступаем к вычислению градиентов для весов и смещения слоя 1. Сначала нам нужно про propagated (пропагировать) градиент через нелинейность ReLU. Для этого мы рассмотрим производную функции ReLU.

![](./resources/layer1Gradients2.webp)

```
with exp():
    l1_output_gradient = l1_activated_gradient * np.heaviside(l1_output, 0)

l1_output_gradient

"""
array([[-0.03213686, -0.00796422],
       [-0.38564227, -0.09557067],
       [-0.        , -0.        ],
       [ 0.        ,  0.        ],
       [ 2.35563151,  0.58377753]])
"""
```

![](./resources/layer1Gradients3.webp)

Переходим к расчету градиента слоя 1.

```
# back propagation
l1_w_gradient =  input.T @ l1_output_gradient
l1_b_gradient = np.mean(l1_output_gradient, axis=0)

# gradient descent
l1_weights -= l1_w_gradient * lr
l1_bias -= l1_b_gradient * lr
```


В предыдущем шаге мы вычислили градиенты для слоя 1 и использовали их для обновления значений весов и смещения. По сути, мы выполнили обратное распространение (backpropagation) через два слоя и применили градиентный спуск в каждом из них.


## 5 Тренировка сети

Вот алгоритм, который мы использовали:

1. Выполнить прямой проход через сеть и получить выходной сигнал.
2. Вычислить градиент для выходных данных сети с помощью функции mse_grad.
3. Для каждого слоя в сети:
    * Определить градиент для выхода до применения нелинейности (если слой включает нелинейность).
    * Вычислить градиент для весов.
    * Вычислить градиент для смещений.
    * Определить градиент для входных данных слоя.
4. Обновить параметры сети с помощью метода градиентного спуска.

Для упрощения мы объединили шаг 4 с шагом 3. Однако, важно понимать, что обратное распространение (backpropagation) соответствует шагу 3, а градиентный спуск - шагу 4.
Разделяя эти шаги, становится более удобным использовать различные варианты градиентного спуска, такие как Adam или RMSProp, для обновления весов. Шаги 3 и 4 обычно называются обратным проходом (backward pass) нейронной сети.

Обратное распространение и градиентный спуск представляют собой наиболее сложные аспекты обучения нейронных сетей.
Концептуально, обратное распространение подразумевает обращение прямого прохода сети для определения наиболее эффективного способа минимизации ошибки. Это влечет за собой распространение градиента ошибки от одного слоя к другому и применение правила цепочки по пути.


## 6. Совокупный код

Примечания:
1. Код доступен в файле `article2_v2.py`
2. Порядок настройки окружения для выполнения изложен в файле  `installation_article2.md`.

В следующем разделе мы объединили все вышеупомянутые темы в класс под названием Neural.

```
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import mean_squared_error
from statistics import mean
from typing import Dict, List, Tuple

np.random.seed(42)

class Neural:
    
    def __init__(self, layers: List[int], epochs: int, 
                 learning_rate: float = 0.001, batch_size: int=32,
                 validation_split: float = 0.2, verbose: int=1):
        self._layer_structure: List[int] = layers
        self._batch_size: int = batch_size
        self._epochs: int = epochs
        self._learning_rate: float = learning_rate
        self._validation_split: float = validation_split
        self._verbose: int = verbose
        self._losses: Dict[str, float] = {"train": [], "validation": []}
        self._is_fit: bool = False
        self.__layers = None
        
    def fit(self, X: np.ndarray, y: np.ndarray) -> None:
        # validation split
        X, X_val, y, y_val = train_test_split(X, y, test_size=self._validation_split, random_state=42)
        # initialization of layers
        self.__layers = self.__init_layers()
        for epoch in range(self._epochs):
            epoch_losses = []
            for i in range(1, len(self.__layers)):
                # forward pass
                x_batch = X[i:(i+self._batch_size)]
                y_batch = y[i:(i+self._batch_size)]
                pred, hidden = self.__forward(x_batch)
                # calculate loss
                loss = self.__calculate_loss(y_batch, pred)
                epoch_losses.append(np.mean(loss ** 2))
                #backward
                self.__backward(hidden, loss)
            valid_preds, _ = self.__forward(X_val)
            train_loss = mean(epoch_losses)
            valid_loss = np.mean(self.__calculate_mse(valid_preds,y_val))
            self._losses["train"].append(train_loss)
            self._losses["validation"].append(valid_loss)
            if self._verbose:
                print(f"Epoch: {epoch} Train MSE: {train_loss} Valid MSE: {valid_loss}")
        self._is_fit = True
        return
    
    def predict(self, X: np.ndarray) -> np.ndarray:
        if self._is_fit == False:
            raise Exception("Model has not been trained yet.")
        pred, hidden = self.__forward(X)
        return pred
    
    def plot_learning(self) -> None:
        plt.plot(self._losses["train"],label="loss")
        plt.plot(self._losses["validation"],label="validation")
        plt.legend()
    
    def __init_layers(self) -> List[np.ndarray]:
        layers = []
        for i in range(1, len(self._layer_structure)):
            layers.append([
                np.random.rand(self._layer_structure[i-1], self._layer_structure[i]) / 5 - .1,
                np.ones((1,self._layer_structure[i]))
            ])
        return layers
    
    def __forward(self, batch: np.ndarray) -> Tuple[np.ndarray, List[np.ndarray]]:
        hidden = [batch.copy()]
        for i in range(len(self.__layers)):
            batch = np.matmul(batch, self.__layers[i][0]) + self.__layers[i][1]
            if i < len(self.__layers) - 1:
                batch = np.maximum(batch, 0)
            # Store the forward pass hidden values for use in backprop
            hidden.append(batch.copy())
        return batch, hidden
    
    def __calculate_loss(self,actual: np.ndarray, predicted: np.ndarray) -> np.ndarray:
        "mse"
        return predicted - actual
    
    
    def __calculate_mse(self, actual: np.ndarray, predicted: np.ndarray) -> np.ndarray:
        return (actual - predicted) ** 2
    
    def __backward(self, hidden: List[np.ndarray], grad: np.ndarray) -> None:
        for i in range(len(self.__layers)-1, -1, -1):
            if i != len(self.__layers) - 1:
                grad = np.multiply(grad, np.heaviside(hidden[i+1], 0))
    
            w_grad = hidden[i].T @ grad
            b_grad = np.mean(grad, axis=0)
    
            self.__layers[i][0] -= w_grad * self._learning_rate
            self.__layers[i][1] -= b_grad * self._learning_rate
            
            grad = grad @ self.__layers[i][0].T
        return
```

Давайте создадим фиктивные данные для тестирования класса Neural.


```
def generate_data():
    # Define correlation values
    corr_a = 0.8
    corr_b = 0.4
    corr_c = -0.2
    
    # Generate independent features
    a = np.random.normal(0, 1, size=100000)
    b = np.random.normal(0, 1, size=100000)
    c = np.random.normal(0, 1, size=100000)
    d = np.random.randint(0, 4, size=100000)
    e = np.random.binomial(1, 0.5, size=100000)
    
    # Generate target feature based on independent features
    target = 50 + corr_a*a + corr_b*b + corr_c*c + d*10 + 20*e + np.random.normal(0, 10, size=100000)
    
    # Create DataFrame with all features
    df = pd.DataFrame({'a': a, 'b': b, 'c': c, 'd': d, 'e': e, 'target': target})
    return df
```

И клиентский код:

```
df = generate_data()

# Separate the features and target
X = df.drop('target', axis=1)
y = df['target']

scaler = StandardScaler()
X = scaler.fit_transform(X)

# Split the dataset into training and testing sets
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
y_train = y_train.to_numpy().reshape(-1,1)
y_test = y_test.to_numpy().reshape(-1,1)

layer_structure = [X_train.shape[1],10,10,1]
nn = Neural(layer_structure, 20, 1e-5, 64, 0.2, 1)

nn.fit(X_train, y_train)

y_pred = nn.predict(X_test)
nn.plot_learning()

print("Test error: ",mean_squared_error(y_test, y_pred))

"""
Epoch: 0 Train MSE: 6066.584227303851 Valid MSE: 5630.972575612107
Epoch: 1 Train MSE: 5870.45827241517 Valid MSE: 5384.024826048717
Epoch: 2 Train MSE: 5584.489636840577 Valid MSE: 4993.952466830458
Epoch: 3 Train MSE: 5127.64238543267 Valid MSE: 4376.563641292963
Epoch: 4 Train MSE: 4408.550555767417 Valid MSE: 3470.967255214888
Epoch: 5 Train MSE: 3370.6165240733935 Valid MSE: 2333.4365011529103
Epoch: 6 Train MSE: 2112.702666917853 Valid MSE: 1245.1547720938968
Epoch: 7 Train MSE: 1001.3618108374816 Valid MSE: 565.5834115291266
Epoch: 8 Train MSE: 396.9514096548994 Valid MSE: 298.31216370120575
Epoch: 9 Train MSE: 198.29006090703072 Valid MSE: 204.83294115572235
Epoch: 10 Train MSE: 139.2931182121901 Valid MSE: 162.0341771457693
Epoch: 11 Train MSE: 113.971621253487 Valid MSE: 138.35491897074462
Epoch: 12 Train MSE: 100.19734344395454 Valid MSE: 124.60170156400542
Epoch: 13 Train MSE: 92.35069581444299 Valid MSE: 116.55999261926036
Epoch: 14 Train MSE: 87.88890529435344 Valid MSE: 111.85169154584908
Epoch: 15 Train MSE: 85.37162170152865 Valid MSE: 109.08001681897412
Epoch: 16 Train MSE: 83.96135084225956 Valid MSE: 107.42929147368837
Epoch: 17 Train MSE: 83.17564386183105 Valid MSE: 106.42800615549532
Epoch: 18 Train MSE: 82.73977092210092 Valid MSE: 105.80581167903857
Epoch: 19 Train MSE: 82.49876360284046 Valid MSE: 105.40815905002043
Test error: 105.40244085384184
"""
```

И кривая обучения:

![learning curves](./resources/code1.webp)

Мы шаг за шагом изучили процесс создания нейронной сети с нуля на Python.  Это практическое руководство предоставило простой и понятный пример, позволив нам получить фундаментальные знания об архитектуре нейронных сетей. Однако важно отметить, что это всего лишь начало нашего путешествия в огромный мир нейронных сетей. Существует множество передовых концепций и методов, которые еще предстоит изучить.

Примечание. Далее в статью запланировано добавление пояснений по каждой функции.
